Micron Document
🎖️GitЯра🎖️

Commit fff95f98db169fca3a5422a141273a0e83e1a968


Parents : 8ff78cb
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-04T15:01:45-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-04T20:01:45Z

feat: distinguish saved coverage estimates in the map layers sheet (#6578)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 464b2da140..bf37a8bbe5 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -848,6 +848,12 @@ last_position_update
latest_alpha_firmware
latest_stable_firmware
latitude
+### LAYER ###
+layer_subtitle
+layer_type_coverage
+layer_type_geojson
+layer_type_kml
+layer_type_network
learn_more
led_heartbeat
led_state

diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapOverlayRenderer.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapOverlayRenderer.kt
index 503e42bf35..861973cedb 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapOverlayRenderer.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapOverlayRenderer.kt
@@ -114,7 +114,10 @@ class FdroidMapOverlayRenderer {
val ok =
stream.use { input ->
when (layer.layerType) {
- LayerType.GEOJSON -> doc.parseGeoJSON(input.bufferedReader().readText())
+ LayerType.GEOJSON,
+ LayerType.COVERAGE,
+ -> doc.parseGeoJSON(input.bufferedReader().readText())
+
LayerType.KML -> doc.parseKMLStream(input, null)
}
}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 0f42d4c995..9325777016 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -1441,7 +1441,9 @@ private fun parseMapLayer(layerType: LayerType, stream: InputStream): DataLayer?
kml.toLayer()
}
- LayerType.GEOJSON -> GeoJsonParser().parse(stream)?.toLayer()?.applySimpleStyleSpec()
+ LayerType.GEOJSON,
+ LayerType.COVERAGE,
+ -> GeoJsonParser().parse(stream)?.toLayer()?.applySimpleStyleSpec()
}
/**

diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayer.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayer.kt
index e673ebe515..29e0465896 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayer.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayer.kt
@@ -30,6 +30,12 @@ import kotlin.uuid.Uuid
enum class LayerType {
KML,
GEOJSON,
+
+ /**
+ * A Site Planner coverage estimate. GeoJSON on the wire and parsed as such, but tracked as its own type so the
+ * layers sheet can distinguish an estimate we generated from a GeoJSON file the user imported.
+ */
+ COVERAGE,
}
data class MapLayerItem(
@@ -39,6 +45,8 @@ data class MapLayerItem(
val isVisible: Boolean = true,
val layerType: LayerType,
val isNetwork: Boolean = false,
+ /** Wall-clock creation time, from the backing file's mtime. Null for network layers, which have no local file. */
+ val createdAt: Long? = null,
/** UI indicator: whether a refresh is in flight (drives the sheet/toolbar spinner). */
val isRefreshing: Boolean = false,
/**
@@ -51,6 +59,48 @@ data class MapLayerItem(
private val KML_EXTENSIONS = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz")
private val GEOJSON_EXTENSIONS = listOf("geojson", "json")
+/** On-disk extension marking a saved coverage estimate, so [LayerType.COVERAGE] survives a restart. */
+const val COVERAGE_EXTENSION = "coverage"
+
+/**
+ * Coverage estimates append a random UUID to their on-disk name so two estimates saved under the same title don't
+ * collide. The layers list rebuilds its display name from that file name, so strip the suffix or the raw UUID shows up
+ * in the UI.
+ */
+private val TRAILING_UUID =
+ Regex("_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", RegexOption.IGNORE_CASE)
+
+/** Recover a layer's display name from its on-disk file name (sans extension). */
+fun displayNameFromFileName(fileNameWithoutExtension: String): String =
+ fileNameWithoutExtension.replace(TRAILING_UUID, "")
+
+/**
+ * Characters not allowed in an on-disk layer file name: path separators, so an import can't traverse out of
+ * `map_layers/`, plus control characters. Everything else — spaces, punctuation, non-Latin scripts — is kept, because
+ * the list rebuilds a layer's display name from this file name and users should see the name they chose.
+ */
+private val FILE_NAME_UNSAFE = Regex("""[/\\\p{Cntrl}]""")
+
+/**
+ * Longest sanitized base name kept, in characters. Bounds the total path length: the UUID suffix and extension add ~46
+ * bytes, and a non-Latin name can reach 4 bytes per character, which would otherwise overrun the 255-byte file name
+ * limit and fail the write.
+ */
+private const val MAX_BASE_NAME_CHARS = 40
+
+/**
+ * Build the on-disk file name for a layer from its [displayName].
+ *
+ * [displayName] is untrusted (a DISPLAY_NAME/lastPathSegment from another app's share/open-with), so separators are
+ * stripped to keep the write inside `map_layers/`. The UUID suffix is load-bearing, not cosmetic: two layers sharing a
+ * display name would otherwise resolve to the same path and the second write would truncate the first. It also means a
+ * name of `..` can never itself be the file name. [displayNameFromFileName] strips it back off for display.
+ */
+fun layerFileName(displayName: String, extension: String): String {
+ val safeBase = displayName.replace(FILE_NAME_UNSAFE, "_").take(MAX_BASE_NAME_CHARS)
+ return "${safeBase}_${Uuid.random()}.$extension"
+}
+
/**
* Resolve a file extension or MIME subtype (e.g. `geojson`, `vnd.geo+json`) to a [LayerType], or null if unsupported.
*/
@@ -59,6 +109,8 @@ fun resolveLayerType(extensionOrMime: String?): LayerType? = when (extensionOrMi
in GEOJSON_EXTENSIONS -> LayerType.GEOJSON
+ COVERAGE_EXTENSION -> LayerType.COVERAGE
+
// MIME subtypes the content resolver may report for GeoJSON that aren't a bare "geojson"/"json".
"geo+json",
"vnd.geo+json",

diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayersManager.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayersManager.kt
index 6d53661933..d59427209b 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayersManager.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLayersManager.kt
@@ -37,13 +37,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.app.MapFileImportBus
+import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MapPrefs
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
-import kotlin.uuid.Uuid
/**
* Flavor-neutral owner of the imported map-layer list, its internal-storage persistence, and the GeoJSON/KML import
@@ -93,10 +93,11 @@ class MapLayersManager(
resolveLayerType(file.extension)?.let { layerType ->
val uri = Uri.fromFile(file)
MapLayerItem(
- name = file.nameWithoutExtension,
+ name = displayNameFromFileName(file.nameWithoutExtension),
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = layerType,
+ createdAt = file.lastModified().takeIf { it > 0 },
)
}
}
@@ -141,18 +142,18 @@ class MapLayersManager(
?: application.contentResolver.getType(uri)?.split('/')?.last()
val layerType = resolveLayerType(extension)
- if (layerType == null) {
+ // resolveLayerType only matches non-null input, so a non-null type guarantees a non-null extension.
+ if (layerType == null || extension == null) {
Logger.withTag(TAG).e("Unsupported map layer file type: $extension")
return@launch
}
- // Sanitize the on-disk name: fileName comes from an untrusted DISPLAY_NAME/lastPathSegment (share/open-with
- // from other apps), so strip anything that could let it escape map_layers/ (mirrors addGeoJsonLayer).
- val safeBase = layerName.replace(FILE_NAME_UNSAFE, "_")
- val finalFileName = if (fileName != null) "$safeBase.$extension" else "layer_${Uuid.random()}.$extension"
- val localFileUri = copyFileToInternalStorage(uri, finalFileName)
+ val localFileUri = copyFileToInternalStorage(uri, layerFileName(layerName, extension))
if (localFileUri != null) {
- _mapLayers.update { it + MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType) }
+ _mapLayers.update {
+ it +
+ MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType, createdAt = nowMillis)
+ }
} else {
Logger.withTag(TAG).e("Failed to copy file to internal storage.")
}
@@ -166,10 +167,17 @@ class MapLayersManager(
fun addGeoJsonLayer(name: String, geoJson: String) {
scope.launch {
val displayName = name.ifBlank { "Coverage" }
- val safeFileName = displayName.replace(FILE_NAME_UNSAFE, "_")
- val uri = writeStringToInternalStorage(geoJson, "${safeFileName}_${Uuid.random()}.geojson")
+ val uri = writeStringToInternalStorage(geoJson, layerFileName(displayName, COVERAGE_EXTENSION))
if (uri != null) {
- _mapLayers.update { it + MapLayerItem(name = displayName, uri = uri, layerType = LayerType.GEOJSON) }
+ _mapLayers.update {
+ it +
+ MapLayerItem(
+ name = displayName,
+ uri = uri,
+ layerType = LayerType.COVERAGE,
+ createdAt = nowMillis,
+ )
+ }
} else {
Logger.withTag(TAG).e("Failed to write GeoJSON layer to internal storage.")
}
@@ -309,8 +317,5 @@ class MapLayersManager(
const val LAYERS_DIR = "map_layers"
const val NETWORK_LAYER_DELIMITER = "|:|"
const val NETWORK_LAYER_FIELDS = 3 // id|:|name|:|uri
-
- // Characters not allowed in an on-disk layer file name; strips path separators so imports can't traverse.
- val FILE_NAME_UNSAFE = Regex("[^A-Za-z0-9._-]")
}
}

diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
index e4b2eb7be0..5c3778c169 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
@@ -45,12 +45,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.app.map.LayerType
import org.meshtastic.app.map.MapLayerItem
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_layer
import org.meshtastic.core.resources.add_network_layer
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.hide_layer
+import org.meshtastic.core.resources.layer_subtitle
+import org.meshtastic.core.resources.layer_type_coverage
+import org.meshtastic.core.resources.layer_type_geojson
+import org.meshtastic.core.resources.layer_type_kml
+import org.meshtastic.core.resources.layer_type_network
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_layer_formats
import org.meshtastic.core.resources.name
@@ -62,7 +69,9 @@ import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer
import org.meshtastic.core.resources.url
import org.meshtastic.core.ui.component.MeshtasticDialog
+import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.Delete
+import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Visibility
@@ -108,54 +117,11 @@ fun CustomMapLayersSheet(
}
} else {
items(mapLayers, key = { it.id }) { layer ->
- ListItem(
- headlineContent = { Text(layer.name) },
- trailingContent = {
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (layer.isNetwork) {
- if (layer.isRefreshing) {
- CircularProgressIndicator(
- modifier = Modifier.size(24.dp).padding(4.dp),
- strokeWidth = 2.dp,
- )
- } else {
- IconButton(onClick = { onRefreshLayer(layer.id) }) {
- Icon(
- imageVector = MeshtasticIcons.Refresh,
- contentDescription = stringResource(Res.string.refresh),
- )
- }
- }
- }
- IconToggleButton(
- checked = layer.isVisible,
- onCheckedChange = { onToggleVisibility(layer.id) },
- ) {
- Icon(
- imageVector =
- if (layer.isVisible) {
- MeshtasticIcons.Visibility
- } else {
- MeshtasticIcons.VisibilityOff
- },
- contentDescription =
- stringResource(
- if (layer.isVisible) {
- Res.string.hide_layer
- } else {
- Res.string.show_layer
- },
- ),
- )
- }
- IconButton(onClick = { onRemoveLayer(layer.id) }) {
- Icon(
- imageVector = MeshtasticIcons.Delete,
- contentDescription = stringResource(Res.string.remove_layer),
- )
- }
- }
- },
+ MapLayerRow(
+ layer = layer,
+ onToggleVisibility = onToggleVisibility,
+ onRemoveLayer = onRemoveLayer,
+ onRefreshLayer = onRefreshLayer,
)
HorizontalDivider()
}
@@ -183,6 +149,94 @@ fun CustomMapLayersSheet(
}
}
+/**
+ * One row of the layers sheet. The leading icon and subtitle exist so a stack of saved coverage estimates is
+ * distinguishable from each other and from imported KML/GeoJSON files — they otherwise share a default name.
+ */
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun MapLayerRow(
+ layer: MapLayerItem,
+ onToggleVisibility: (String) -> Unit,
+ onRemoveLayer: (String) -> Unit,
+ onRefreshLayer: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val typeLabel =
+ stringResource(
+ when {
+ layer.isNetwork -> Res.string.layer_type_network
+ layer.layerType == LayerType.COVERAGE -> Res.string.layer_type_coverage
+ layer.layerType == LayerType.KML -> Res.string.layer_type_kml
+ else -> Res.string.layer_type_geojson
+ },
+ )
+ ListItem(
+ modifier = modifier,
+ headlineContent = { Text(layer.name) },
+ supportingContent = {
+ Text(
+ text =
+ layer.createdAt?.let {
+ stringResource(Res.string.layer_subtitle, typeLabel, DateFormatter.formatDateTimeShort(it))
+ } ?: typeLabel,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector =
+ if (layer.layerType == LayerType.COVERAGE) {
+ MeshtasticIcons.CellTower
+ } else {
+ MeshtasticIcons.Layers
+ },
+ contentDescription = typeLabel,
+ )
+ },
+ trailingContent = {
+ MapLayerActions(
+ layer = layer,
+ onToggleVisibility = onToggleVisibility,
+ onRemoveLayer = onRemoveLayer,
+ onRefreshLayer = onRefreshLayer,
+ )
+ },
+ )
+}
+
+/** Per-row refresh (network layers only), visibility toggle, and delete controls. */
+@Composable
+private fun MapLayerActions(
+ layer: MapLayerItem,
+ onToggleVisibility: (String) -> Unit,
+ onRemoveLayer: (String) -> Unit,
+ onRefreshLayer: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
+ if (layer.isNetwork) {
+ if (layer.isRefreshing) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp).padding(4.dp), strokeWidth = 2.dp)
+ } else {
+ IconButton(onClick = { onRefreshLayer(layer.id) }) {
+ Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = stringResource(Res.string.refresh))
+ }
+ }
+ }
+ IconToggleButton(checked = layer.isVisible, onCheckedChange = { onToggleVisibility(layer.id) }) {
+ Icon(
+ imageVector = if (layer.isVisible) MeshtasticIcons.Visibility else MeshtasticIcons.VisibilityOff,
+ contentDescription =
+ stringResource(if (layer.isVisible) Res.string.hide_layer else Res.string.show_layer),
+ )
+ }
+ IconButton(onClick = { onRemoveLayer(layer.id) }) {
+ Icon(imageVector = MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.remove_layer))
+ }
+ }
+}
+
@Suppress("ModifierMissing") // wraps MeshtasticDialog, which owns its own layout; no meaningful modifier slot
@Composable
fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {

diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLayerResolutionTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLayerResolutionTest.kt
index 91d671d991..ee27db0db7 100644
Binary files a/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLayerResolutionTest.kt and b/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLayerResolutionTest.kt differ

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 15e3830b9f..8fd76e4ca7 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -875,6 +875,12 @@
<string name="latest_alpha_firmware">Latest alpha</string>
<string name="latest_stable_firmware">Latest stable</string>
<string name="latitude">Latitude</string>
+ <!-- LAYER -->
+ <string name="layer_subtitle">%1$s • %2$s</string>
+ <string name="layer_type_coverage">Coverage estimate</string>
+ <string name="layer_type_geojson" translatable="false">GeoJSON</string>
+ <string name="layer_type_kml" translatable="false">KML</string>
+ <string name="layer_type_network">Network layer</string>
<string name="learn_more">Learn more</string>
<string name="led_heartbeat">LED Heartbeat</string>
<string name="led_state">LED state</string>

Served by rngit 1.5.0 - Generated in 0.24s